feat: configurable runtime-token header to avoid gateway Authorization collision [HYBIM-866] - #253
Closed
josjeon wants to merge 8 commits into
Closed
Conversation
josjeon
force-pushed
the
hybim-741-runtime-token-configurable-header
branch
4 times, most recently
from
July 23, 2026 22:15
19dfb5a to
6c4e9c7
Compare
josjeon
force-pushed
the
hybim-741-runtime-token-configurable-header
branch
from
July 23, 2026 22:21
6c4e9c7 to
a1d2410
Compare
josjeon
marked this pull request as ready for review
July 28, 2026 18:58
josjeon
pushed a commit
to josjeon/agent-control
that referenced
this pull request
Aug 7, 2026
The positional-order regression guard stopped at api_key_header (6th positional), but the compatibility regression begins at controls_file (7th), so the prior test would also pass against the broken signature. Supply positionals through target_id (16th) and assert target_type/ target_id bind to their pre-change slots; runtime_token_header stays last and unset. Verified this fails if the header is inserted before target_id. Addresses review comment on PR agentcontrol#253. Co-Authored-By: Claude <noreply@anthropic.com>
josjeon
pushed a commit
to josjeon/agent-control
that referenced
this pull request
Aug 7, 2026
The positional-order regression guard stopped at api_key_header (6th positional), but the compatibility regression begins at controls_file (7th), so the prior test would also pass against the broken signature. Supply positionals through target_id (16th) and assert target_type/ target_id bind to their pre-change slots; runtime_token_header stays last and unset. Verified this fails if the header is inserted before target_id. Addresses review comment on PR agentcontrol#253. Co-Authored-By: Claude <noreply@anthropic.com>
josjeon
force-pushed
the
hybim-741-runtime-token-configurable-header
branch
from
August 7, 2026 20:10
1f122bb to
53a6f6e
Compare
abhinav-galileo
approved these changes
Aug 11, 2026
namrataghadi-galileo
approved these changes
Aug 14, 2026
josjeon
pushed a commit
to josjeon/agent-control
that referenced
this pull request
Aug 14, 2026
The positional-order regression guard stopped at api_key_header (6th positional), but the compatibility regression begins at controls_file (7th), so the prior test would also pass against the broken signature. Supply positionals through target_id (16th) and assert target_type/ target_id bind to their pre-change slots; runtime_token_header stays last and unset. Verified this fails if the header is inserted before target_id. Addresses review comment on PR agentcontrol#253. Co-Authored-By: Claude <noreply@anthropic.com>
josjeon
force-pushed
the
hybim-741-runtime-token-configurable-header
branch
from
August 14, 2026 16:03
53a6f6e to
2b94680
Compare
…ization collision HYBIM-741. When Agent Control runs behind the O11y gateway, the gateway overwrites `Authorization` with its own downstream identity JWT, clobbering AC's runtime-eval token on the hot path. Make LocalJwtVerifyProvider read the runtime token from a configurable header, selected by AGENT_CONTROL_RUNTIME_TOKEN_HEADER (default `Authorization`). Behind the gateway, point it at a dedicated header (e.g. `X-Agent-Control-Runtime-Token`) so the runtime token and the gateway's Authorization JWT no longer collide. - `Authorization` keeps the mandatory `Bearer` scheme (existing contract). - A dedicated header accepts the raw token (Bearer optional). - Default unchanged: with the env unset, behavior is identical to before. Server-side only; the SDK send-side change is tracked separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… gateway Authorization collision HYBIM-741. When Agent Control runs behind the O11y gateway, the gateway overwrites `Authorization` with its own downstream identity JWT, clobbering AC's runtime-eval token on the hot path. Make the runtime token ride a configurable header on both sides, selected by AGENT_CONTROL_RUNTIME_TOKEN_HEADER (default `Authorization`): - Server: LocalJwtVerifyProvider reads the token from the configured header; Bearer stays mandatory on Authorization, raw token accepted on a dedicated header. - SDK: AgentControlClient sends the token on the configured header (raw on a dedicated header, Bearer on Authorization) and suppresses the API-key fallback when the runtime token rides its own header, so a runtime request carries a single credential. Default unchanged: with the env unset, behavior is identical to before. Point both sides at a dedicated header (e.g. X-Agent-Control-Runtime-Token) behind the gateway so the runtime token and the gateway Authorization JWT no longer collide. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Preserve the outer gateway credential: _AgentControlAuth no longer suppresses the API key when the runtime token rides a dedicated header. The existing same-header guard already prevents collisions, so the API key remains available as the outer credential a gateway may require. - Preserve positional argument order: move runtime_token_header to the end of AgentControlClient.__init__ so existing positional callers are unaffected. - Expose runtime_token_header through the high-level SDK: agent_control.init() accepts it, stores it in session state, and threads it into the evaluation clients (evaluation.py, control_decorators.py); cleared on reset. - Add app-level /api/v1/evaluation tests exercising config wiring + Operation.RUNTIME_USE routing: runtime token on a dedicated header with an outer Authorization gateway JWT is accepted; a token on Authorization is ignored when a custom header is configured. Co-Authored-By: Claude <noreply@anthropic.com>
…HYBIM-866] Move runtime_token_header to the end of agent_control.init()'s signature (after target_id, before **kwargs) so existing positional callers are not shifted — previously it sat before controls_file, binding a controls path to the header and shifting every later arg. Mirrors the AgentControlClient fix. Add a positional-compatibility test. Addresses PR review. Co-Authored-By: Claude <noreply@anthropic.com>
…ate gateway 401 [HYBIM-866] Addresses PR review (Namrata): - Validate the runtime-token header against the RFC 7230 field-name grammar, not just non-blank (P2). New shared validate_http_field_name / resolve_runtime_token_header in the SDK runtime_auth, and a matching validate_http_field_name on the server (local_jwt). Applied in AgentControlClient.__init__, agent_control.init() (before stopping the refresh loop / mutating session state), LocalJwtVerifyProvider, and config._resolve_runtime_token_header — so an invalid header fails at construction/startup instead of on the first evaluation. - Distinguish a gateway 401 from a runtime-token failure (P1). When the runtime token rides a dedicated header (Authorization then carries the gateway's own identity), a bare 401 is ambiguous; _should_refresh_runtime_token now only refreshes on 401 there when WWW-Authenticate flags the token invalid, so a gateway 401 no longer evicts a valid runtime token and masks the original error. Default (token on Authorization) behavior is unchanged. Tests: SDK field-name validation (param + env), 401-disambiguation unit tests; server field-name validation for provider + resolver. SDK 59 pass, server 138 pass. Co-Authored-By: Claude <noreply@anthropic.com>
The positional-order regression guard stopped at api_key_header (6th positional), but the compatibility regression begins at controls_file (7th), so the prior test would also pass against the broken signature. Supply positionals through target_id (16th) and assert target_type/ target_id bind to their pre-change slots; runtime_token_header stays last and unset. Verified this fails if the header is inserted before target_id. Addresses review comment on PR agentcontrol#253. Co-Authored-By: Claude <noreply@anthropic.com>
Two import blocks in the new test file (top-level, and the local import in test_runtime_token_rejects_management_token_passed_to_runtime_verify) were un-sorted per ruff I001. Auto-fixed so CI lint passes. Co-Authored-By: Claude <noreply@anthropic.com>
josjeon
force-pushed
the
hybim-741-runtime-token-configurable-header
branch
from
August 14, 2026 16:30
2b94680 to
3f22a81
Compare
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
namrataghadi-galileo
pushed a commit
that referenced
this pull request
Aug 14, 2026
…orization collision [HYBIM-866] (#258) _Recreated from #253 on an upstream branch (not a fork) so CI secrets (SPEAKEASY_API_KEY) are available. Same commits, signed. Original PR: #253._ --- ## Problem When Agent Control runs behind the O11y gateway, the gateway overwrites `Authorization` with its own downstream identity JWT — clobbering AC's runtime-eval token on the hot path (HYBIM-866, under epic HYBIM-741). The runtime token and the gateway's identity JWT both want the `Authorization` header. ## Fix Make the runtime token ride a **configurable header** on both sides, selected by `AGENT_CONTROL_RUNTIME_TOKEN_HEADER` (default `Authorization`). Behind the gateway, point both sides at a dedicated header (e.g. `X-Agent-Control-Runtime-Token`); the runtime token rides that header while the gateway keeps `Authorization` for its identity JWT — no collision. - **`Authorization`**: keeps the mandatory `Bearer` scheme (existing contract). - **Dedicated header**: carries the **raw** token (no `Bearer` prefix). - **Default unchanged**: with the env unset, behavior is byte-identical to today. ### Server (verify side) - `auth_framework/providers/local_jwt.py`: `LocalJwtVerifyProvider` takes a `header_name` (default `Authorization`) and reads the token from it. `Bearer` required only on `Authorization`; raw token accepted on a dedicated header. - `auth_framework/config.py`: `_resolve_runtime_token_header()` reads `AGENT_CONTROL_RUNTIME_TOKEN_HEADER` (blank → default), passed into the provider when runtime mode is `jwt`. ### SDK (send side) - `sdks/python/.../client.py`: `AgentControlClient` gains a `runtime_token_header` param (+ same env var). Sends the runtime token on the configured header — raw on a dedicated header, `Bearer` on `Authorization` (single `_format_runtime_token` helper is the sole authority for that rule). - The API key is **preserved as the outer gateway credential**: when the runtime token rides a dedicated header, the API key still rides its own header (`X-API-Key`), so a request can authenticate at the gateway while the runtime JWT is verified by Agent Control. The existing same-header guard prevents any collision. - High-level SDK: `agent_control.init()` accepts `runtime_token_header`, stores it in session state, and threads it into the evaluation clients (`evaluation.py`, `control_decorators.py`); it is cleared on reset. ## Configuration (behind the gateway) ``` # server AGENT_CONTROL_RUNTIME_AUTH_MODE=jwt AGENT_CONTROL_RUNTIME_TOKEN_SECRET=<secret> AGENT_CONTROL_RUNTIME_TOKEN_HEADER=X-Agent-Control-Runtime-Token # SDK (must match the server header) AgentControlClient(..., runtime_token_header="X-Agent-Control-Runtime-Token") # or agent_control.init(..., runtime_token_header="X-Agent-Control-Runtime-Token") # or AGENT_CONTROL_RUNTIME_TOKEN_HEADER=X-Agent-Control-Runtime-Token ``` ## Tests - **Server** (`test_auth_framework.py`): default Bearer path; default rejects raw on `Authorization`; dedicated header reads raw token and coexists with a gateway `Authorization` JWT; Bearer also accepted on dedicated header; missing-header error names the configured header; blank `header_name` rejected; env resolver (unset → default, set → trimmed, whitespace → default). - **Server, app-level** (`test_runtime_token_exchange_endpoint.py`): end-to-end through `/api/v1/evaluation` exercising config wiring + `Operation.RUNTIME_USE` routing — runtime token on a dedicated header with an outer `Authorization` gateway JWT is accepted; a token presented on `Authorization` is rejected (401) when a custom header is configured. - **SDK** (`test_client.py`): header resolution (param/env/default, blank rejected, whitespace-env fallback); raw token on dedicated header with `Authorization` free while the API key is preserved on its own header; default sends `Bearer` on `Authorization`; auto-mode fallback keeps the API key when the exchange is unavailable. - **SDK, high-level** (`test_init_validation.py`): `init()` stores `runtime_token_header` in session state; defaults to `None` when unset; validates the header up front (blank and bad field-name rejected before state is mutated); a positional call through `target_id` proves the new param is appended last and does not shift any existing slot (`controls_file` onward). ## Live validation on lab0 (through the real O11y gateway) Validated the branch on lab0 behind the actual O11y api-gateway (the gateway that owns `Authorization` for its own identity JWT — the real collision scenario). - **Deploy:** built the branch server image, pushed it to a personal `docker-test.repo.splunkdev.net/user-<name>/agent-control:<tag>` namespace (a path a personal Artifactory token can write and lab0 can pull), then `kubectl set image` the lab0 `agent-control` deploy at it with `AGENT_CONTROL_RUNTIME_TOKEN_HEADER=X-Agent-Control-Runtime-Token`. Rolled back to the released image afterward. - **Full flow returns 200, with a real org and log_stream.** `runtime-token-exchange` returns 200 and mints a real server-issued runtime JWT for the log_stream; the evaluation call carries that token on the **custom header** through the gateway and returns **200** with a real result (`is_safe: true`). - **The collision is handled.** The same token presented on `Authorization` is rejected with 401 ("Missing X-Agent-Control-Runtime-Token"), because the server reads only the dedicated header. So even when the gateway overwrites `Authorization` with its own identity JWT, the runtime JWT survives on `X-Agent-Control-Runtime-Token`. - **Correction from an earlier draft of this PR:** an initial run hit `502` (upstream `422`) on the exchange, which looked like a capability/provisioning gap. That was a test-input error, not a real gap: the request used a made-up `target_id` that was not a real log_stream, so the exchange could not resolve it. With a real log_stream id the whole flow returns 200. Nothing is blocked on provisioning. - **Not exercised in this run:** a control actually firing (steer/deny) and control spans landing in Galileo (no control was bound to the log_stream, so the eval ran clean). That is separate from the header change and was already shown on the devstack. ## Security notes - **Header isolation**: with a dedicated header configured, the verifier reads only that header — a token presented on `Authorization` is ignored, so it can't be smuggled past the gateway boundary. - **No token leakage**: auth error messages reference the header name only, never the token value. - Signature / scope / target-binding checks (`verify_runtime_token`) are unchanged. ## Backward compatibility Safe. No backward compatibility issue. The change is default-preserving. `runtime_token_header` defaults to `Authorization` on both the SDK and server, and an unset env var falls back to that default. With no config, behavior is byte-identical to today: the SDK still sends `Bearer <token>` on `Authorization`, and the server still requires the Bearer scheme there. All four default-path branches were traced to confirm. One behavior change on the default path, and it's an improvement: a malformed `Authorization: Bearer ` with only trailing whitespace used to return an empty token and fail deep in signature verification. Now it's rejected up front with a clean `AUTH_MISSING_KEY`. No valid request changes. ### Operational notes (expected tradeoffs, not design risks) - **Two-sided header contract.** Setting `AGENT_CONTROL_RUNTIME_TOKEN_HEADER` on only one side breaks runtime auth (401 on every eval). This is intrinsic to any configurable-header feature, not a regression, and it fails closed: a mismatch denies auth, never bypasses it. Neither side can validate the other's value since they run as separate processes, so the intended safeguard is a startup log of the resolved header on both sides, making a mismatch a quick log diff rather than a debugging session. - **API-key retention on a dedicated header.** `_AgentControlAuth` now leaves the API key in place when the runtime token rides a dedicated header, so the key can act as the outer gateway credential. This is deliberate and required by the two-credential model: the API key authenticates at the gateway, and the runtime JWT is verified by Agent Control. The default path is unchanged. It is called out only because it is the one change on every request's auth path, which makes it the right spot to focus review. ## Notes / scope - Backwards compatible: unset env → identical behavior. No change to the API-key or `none` runtime modes. - Design follows the O11y api-service precedent: support both auth methods, gateway stays neutral, opt-in, default preserved. - Server-side tests require the repo's Postgres test fixture (run in CI); the SDK suite runs standalone. - Validated end-to-end on a devstack: with the SDK sending the token on `X-Agent-Control-Runtime-Token` and the server reading the same header, runtime JWT exchange, control steering, and control-span ingestion all worked. This confirms the SDK and server agree on the custom header. It does not yet exercise the gateway-collision path (the devstack has no O11y gateway overwriting `Authorization`). - Gateway-collision validation on lab0 is done and green end-to-end (see "Live validation on lab0" above): with a real org and log_stream, `runtime-token-exchange` returns 200, the evaluation call rides `X-Agent-Control-Runtime-Token` through the real O11y gateway and returns 200 (`is_safe: true`), and the same token on `Authorization` is rejected 401. Control firing and span ingestion were not exercised in this run (no control bound to the log_stream), and are separate from the header change. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Collaborator
Author
|
#258 <-- merged with a new MR |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
When Agent Control runs behind the O11y gateway, the gateway overwrites
Authorizationwith its own downstream identity JWT — clobbering AC's runtime-eval token on the hot path (HYBIM-866, under epic HYBIM-741). The runtime token and the gateway's identity JWT both want theAuthorizationheader.Fix
Make the runtime token ride a configurable header on both sides, selected by
AGENT_CONTROL_RUNTIME_TOKEN_HEADER(defaultAuthorization). Behind the gateway, point both sides at a dedicated header (e.g.X-Agent-Control-Runtime-Token); the runtime token rides that header while the gateway keepsAuthorizationfor its identity JWT — no collision.Authorization: keeps the mandatoryBearerscheme (existing contract).Bearerprefix).Server (verify side)
auth_framework/providers/local_jwt.py:LocalJwtVerifyProvidertakes aheader_name(defaultAuthorization) and reads the token from it.Bearerrequired only onAuthorization; raw token accepted on a dedicated header.auth_framework/config.py:_resolve_runtime_token_header()readsAGENT_CONTROL_RUNTIME_TOKEN_HEADER(blank → default), passed into the provider when runtime mode isjwt.SDK (send side)
sdks/python/.../client.py:AgentControlClientgains aruntime_token_headerparam (+ same env var). Sends the runtime token on the configured header — raw on a dedicated header,BeareronAuthorization(single_format_runtime_tokenhelper is the sole authority for that rule).X-API-Key), so a request can authenticate at the gateway while the runtime JWT is verified by Agent Control. The existing same-header guard prevents any collision.agent_control.init()acceptsruntime_token_header, stores it in session state, and threads it into the evaluation clients (evaluation.py,control_decorators.py); it is cleared on reset.Configuration (behind the gateway)
Tests
test_auth_framework.py): default Bearer path; default rejects raw onAuthorization; dedicated header reads raw token and coexists with a gatewayAuthorizationJWT; Bearer also accepted on dedicated header; missing-header error names the configured header; blankheader_namerejected; env resolver (unset → default, set → trimmed, whitespace → default).test_runtime_token_exchange_endpoint.py): end-to-end through/api/v1/evaluationexercising config wiring +Operation.RUNTIME_USErouting — runtime token on a dedicated header with an outerAuthorizationgateway JWT is accepted; a token presented onAuthorizationis rejected (401) when a custom header is configured.test_client.py): header resolution (param/env/default, blank rejected, whitespace-env fallback); raw token on dedicated header withAuthorizationfree while the API key is preserved on its own header; default sendsBeareronAuthorization; auto-mode fallback keeps the API key when the exchange is unavailable.test_init_validation.py):init()storesruntime_token_headerin session state; defaults toNonewhen unset; validates the header up front (blank and bad field-name rejected before state is mutated); a positional call throughtarget_idproves the new param is appended last and does not shift any existing slot (controls_fileonward).Live validation on lab0 (through the real O11y gateway)
Validated the branch on lab0 behind the actual O11y api-gateway (the gateway that
owns
Authorizationfor its own identity JWT — the real collision scenario).docker-test.repo.splunkdev.net/user-<name>/agent-control:<tag>namespace (apath a personal Artifactory token can write and lab0 can pull), then
kubectl set imagethe lab0agent-controldeploy at it withAGENT_CONTROL_RUNTIME_TOKEN_HEADER=X-Agent-Control-Runtime-Token. Rolled backto the released image afterward.
runtime-token-exchangereturns 200 and mints a real server-issued runtime JWT for the log_stream; the
evaluation call carries that token on the custom header through the gateway
and returns 200 with a real result (
is_safe: true).Authorizationisrejected with 401 ("Missing X-Agent-Control-Runtime-Token"), because the server
reads only the dedicated header. So even when the gateway overwrites
Authorizationwith its own identity JWT, the runtime JWT survives onX-Agent-Control-Runtime-Token.502(upstream
422) on the exchange, which looked like a capability/provisioning gap.That was a test-input error, not a real gap: the request used a made-up
target_idthat was not a real log_stream, so the exchange could not resolve it.With a real log_stream id the whole flow returns 200. Nothing is blocked on
provisioning.
spans landing in Galileo (no control was bound to the log_stream, so the eval ran
clean). That is separate from the header change and was already shown on the
devstack.
Security notes
Authorizationis ignored, so it can't be smuggled past the gateway boundary.verify_runtime_token) are unchanged.Backward compatibility
Safe. No backward compatibility issue.
The change is default-preserving.
runtime_token_headerdefaults toAuthorizationon both the SDK and server, and an unset env var falls back to that default. With no config, behavior is byte-identical to today: the SDK still sendsBearer <token>onAuthorization, and the server still requires the Bearer scheme there. All four default-path branches were traced to confirm.One behavior change on the default path, and it's an improvement: a malformed
Authorization: Bearerwith only trailing whitespace used to return an empty token and fail deep in signature verification. Now it's rejected up front with a cleanAUTH_MISSING_KEY. No valid request changes.Operational notes (expected tradeoffs, not design risks)
AGENT_CONTROL_RUNTIME_TOKEN_HEADERon only one side breaks runtime auth (401 on every eval). This is intrinsic to any configurable-header feature, not a regression, and it fails closed: a mismatch denies auth, never bypasses it. Neither side can validate the other's value since they run as separate processes, so the intended safeguard is a startup log of the resolved header on both sides, making a mismatch a quick log diff rather than a debugging session._AgentControlAuthnow leaves the API key in place when the runtime token rides a dedicated header, so the key can act as the outer gateway credential. This is deliberate and required by the two-credential model: the API key authenticates at the gateway, and the runtime JWT is verified by Agent Control. The default path is unchanged. It is called out only because it is the one change on every request's auth path, which makes it the right spot to focus review.Notes / scope
noneruntime modes.X-Agent-Control-Runtime-Tokenand the server reading the same header, runtime JWT exchange, control steering, and control-span ingestion all worked. This confirms the SDK and server agree on the custom header. It does not yet exercise the gateway-collision path (the devstack has no O11y gateway overwritingAuthorization).runtime-token-exchangereturns 200, the evaluation call ridesX-Agent-Control-Runtime-Tokenthrough the real O11y gateway and returns 200 (is_safe: true), and the same token onAuthorizationis rejected 401. Control firing and span ingestion were not exercised in this run (no control bound to the log_stream), and are separate from the header change.